✅HAMPU: 画像アップロード機能 #137
https://gyazo.com/5c2eff9f580a8b73af0fe4a4416e340e
初期構想
現在、URL指定で画像を指定できる
これに加えて、 ユーザーはスマホから頒布物の写真をアップロードできるようになる(ボタンの背景になる)
イベント削除時にR2の画像も自動的に削除される
仕様
1枚10MBまで(iPhoneのスクショサイズ)
出力画像はAVIFに統一する
アーキテクチャ
めちゃくちゃ圧縮してR2に保存
有償の場合、cloudflare imagesに保存しても良いが無料枠を使い倒す
自前が厳しくなったらこの手のサービスを使っても良い
予想工数:Claude Code 5-hour limit x 5→8→12
実際:🍅🍅🍅🍅🍅🍅🍅🍅🍅🍅🍅
3日半かかった。コードはテストを含めて1万行。
TODO
写真が使えない
HEIC対応してないから?
ファイルサイズが大きい
スクショはいける(pngだから)
アップロード中...の文言はいらない。アニメだけで十分
画像を添付した時に?圧縮の関係でwebpなのにavifになってるし
そもそも名前を表示する必要がない
✅スマホからアップロードしようとすると写真撮影モードになってしまう #154
✅preview環境でもproductionとして投稿される
✅D&Dしたい #152
Claude Sonnet 4.iconGPT-5.iconと設計を検討しADR-017を作成
APIエンドポイント設計
画像アップロード
EXIFの削除
圧縮
イベント削除時の画像削除
ADR-017ベースに実装を開始
圧縮はクライアントとサーバーの両方で行う
イベント会場では回線が逼迫しがちなのでクライアント圧縮必須
R2バケットを作成する
アイテム追加した時に画像が出ないバグがあった
修正した。仕様をテストにする際に誤っていた。だからテストはあるのに気づけなかった。こういうのはまずい(でも仕様がテストになっていないよりはずっと良い)
基素.iconローカルで実際にアップロードされたファイルを確認する方法はないのだろうか?
ローカルR2アクセス方法(Miniflare/Wrangler)
Miniflareは意図的にローカルR2への直接CLIアクセスを提供していません。これはベストプラクティスとしての設計決定で、実際のWorker APIを通じてアクセスすることで、よりプロダクション環境に近いテスト環境を提供しています。
現在最も適切な方法は、既存の/api/images/エンドポイントを使用するか、デバッグ用の管理エンドポイントを追加することです。
1. サポートされているバインディング
Cloudflare公式ドキュメントによると、R2はローカルシミュレーションをサポートしています:
table:_
Binding Local simulations Remote binding connections
R2 ✅ ✅
2. 直接的なCLIコマンドは提供されていない
ドキュメントを見る限り、MiniflareはローカルR2の内容をリストするCLIコマンドを意図的に提供していません。
これは以下の理由からです:
開発環境の分離: ローカルとプロダクションの混同防止
Worker経由テスト: 実際のアプリケーション動作に近い環境でのテスト
データ永続化: --persist-toオプションで指定したディレクトリに保存
理由になってなくない?これがないから混同しないわけじゃないだろう基素.icon
3. 推奨されるアクセス方法
A) Worker API経由(推奨)
開発サーバー経由で画像にアクセス
B) 開発用エンドポイント追加
デバッグ用にR2オブジェクト一覧表示エンドポイントを追加可能
C) リモートバインディング使用
ローカル開発でプロダクションR2に接続
$ wrangler dev --x-remote-bindings
4. ローカルデータ保存場所
デフォルト: .wrangler/state/v3/r2/bucket-name/
カスタム: --persist-to <DIRECTORY> で指定可能
R2は課金情報を有効にしないと作成できない
チェック項目
アイテムを削除した時に画像が削除される
イベントが削除した時に画像が削除される
Claude Sonnet 4.icon
✅ イベント削除時の画像削除(実装済み)
Durable Objects: hampu-do.ts:389 でイベント削除時に deleteEventImages() を呼び出し
Cronジョブ: app.ts:211 で期限切れイベント削除時に画像削除
削除機能: production/{eventId}/ と preview/{eventId}/ の両環境から一括削除
再帰削除: truncated対応で1000件以上も完全削除
❌ アイテム削除時の画像削除(未実装)
現在の状況:
アイテム削除時に画像を削除する機能は実装されていない
DeleteItem UseCaseは単純にDBからアイテムを削除するのみ
backgroundImageUrlに関連する画像がR2に残存する可能性
してる基素.icon
必要な実装:
アイテム削除前に画像URLから画像キーを抽出
R2から該当画像を削除する処理を追加
結論
イベント削除時の画像削除は完全実装済みだが、アイテム削除時の画像削除は未実装。ADR017の要求を完全に満たすには、アイテム個別削除時の画像削除機能の追加が必要。
解像度の圧縮効果
📐 解像度とファイルサイズの関係
理論値 vs 実際
理論: 解像度が半分 = ファイルサイズが1/4(面積比)
実際: そこまで劇的には減らない(圧縮アルゴリズムの影響)
🧮 具体例(JPEG/WebPの場合)
同じ品質30%での比較
table:_
解像度 面積比 理論サイズ 実際のサイズ 効果
1600×1200 100% 1.0MB 1.0MB -
1120×840 49% 0.49MB 0.6-0.7MB 30-40%減
784×588 24% 0.24MB 0.35-0.45MB 55-65%減
549×412 12% 0.12MB 0.2-0.3MB 70-80%減
なぜ理論値より効果が薄い?
1. 圧縮の限界
JPEGのDCT変換は8×8ブロック単位
小さな画像でも最低限のヘッダー情報が必要
2. 画像内容による差
単調な画像: 理論値に近い効果
複雑な画像: 効果が薄い
📊 品質調整との比較効果
解像度のみ変更
1600×1200, 品質30% → 1.0MB
1120×840, 品質30% → 0.65MB (35%減)
784×588, 品質30% → 0.4MB (60%減)
品質のみ変更
1600×1200, 品質30% → 1.0MB
1600×1200, 品質20% → 0.75MB (25%減)
1600×1200, 品質10% → 0.45MB (55%減)
両方変更(現在の方式)
1600×1200, 品質30% → 1.0MB
1120×840, 品質25% → 0.5MB (50%減) ← 効果的!
💡 結論
解像度単独でも相当効果的ですが、品質も併用する方が:
より少ない解像度低下で目標達成
ユーザーにとって実用的な画像サイズを維持
段階的に調整できるため制御しやすい
ぎゃー mobile Safariはcanvas.toBlob()でwebpにすら変換できないのか!!
JPEGに対応することにした
GPT-5.iconまとめ: モバイルブラウザでユーザー画像をアップロードする際の軽量化戦略
Safariの制約
canvas.toBlob('image/webp') などでWebP/AVIFへ直接圧縮は不可
表示はできるがエンコードはできない
選択肢
1. JPEG圧縮(最も現実的)
canvas.toBlob('image/jpeg', quality) を利用
Safariでも動作
quality=0.6〜0.8程度で画質と容量のバランス良好
高解像度画像は縮小(例: 最大辺1280px)を併用
2. ブラウザごとに分岐
Chrome/Firefox/Edge: WebP/AVIF
Safari: JPEG
3. WASMコーデック利用
全ブラウザでWebP/AVIF圧縮可能
ただし初回ロードが重い・処理が遅い・実装複雑
モバイル向け大量ユーザー用途には不向き
4. サーバー変換
クライアントはJPEGで送信、サーバー側でWebP/AVIFへ再圧縮
ユーザー体験は軽く、配信最適化も可能
実務的な結論
「アップロード帯域削減」が目的ならクライアントでJPEG圧縮が最適
追加最適化したければサーバーでWebP/AVIF変換して配信するのが現実解
WASMはロード重く端末負荷も高いため基本は不要
GPT-5.iconSquoosh の wasm コーデックを Cloudflare Workers 環境などでも使いやすくしたラッパー
メリット
WASMコーデックを抽象化してくれるので、直接 libwebp / mozjpeg / libavif を触るよりシンプル
Cloudflare Workers は V8 isolate 環境なので WebAssembly 動作OK → jSquash はそのまま動く
アップロード後に Workers で圧縮すれば、クライアントはJPEGでもよく、サーバー保存時にWebP/AVIFへ統一できる
Edge処理なので「ユーザーが送信した直後に軽量化」でき、オリジン帯域を節約できる
デメリット・注意点
WASMバイナリをWorkersにバンドルするとサイズが大きい(Cloudflareのデプロイ制限に注意: 1MB単ファイル/最大10MB程度)
圧縮処理はCPUを食う → WorkersのCPU実行時間制限に引っかかる可能性あり(特に大きな画像を多数処理すると危険)
圧縮速度はブラウザWASMと同様で、ネイティブに比べれば遅い
コスト面: WorkersはCPU時間課金があるので、大量処理するとランニングコストが増える
結論
ユーザーの転送量を減らす目的ならクライアントJPEG圧縮を第一にするべき
その上で Workers + jSquash で WebP/AVIF へ変換して保存・配信 はかなり良い組み合わせ
ただし「Workersの制限に収まるサイズ・処理量か」を先に検証すべき
基素.icon帯域を減らすのが目的なので見送り
GPT-5.iconcanvas.toBlob(JPEG/WebP) と WASM経由のAVIF圧縮 では、桁が違うレベルでパフォーマンス差があります。
canvas.toBlob はブラウザ内部のC++実装でハード最適化されている
AVIF圧縮は元々重い上に、ブラウザ内でWASM実行するのでネイティブよりさらに遅い
参考値(モバイル端末・フルHD画像 1920×1080 の場合):
canvas.toBlob('image/jpeg', 0.7)
50〜150ms 程度で完了(ネイティブ実装で高速)
CPU負荷も低い
jSquash / Squoosh AVIF (WASM)
1〜3秒かかることが多い
端末が古いと 5〜10秒かかる場合もある
CPU使用率100%近くになる → UIがカクつく
メモリ消費も増える(数十MB以上)